1<?php 2/** 3 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 4 * @author Esther Brunner <wikidesign@gmail.com> 5 */ 6 7use dokuwiki\Utf8\PhpString; 8 9/** 10 * Class admin_plugin_discussion 11 */ 12class admin_plugin_discussion extends DokuWiki_Admin_Plugin 13{ 14 15 /** 16 * @return int 17 */ 18 public function getMenuSort() 19 { 20 return 200; 21 } 22 23 /** 24 * @return bool 25 */ 26 public function forAdminOnly() 27 { 28 return false; 29 } 30 31 public function handle() 32 { 33 global $lang, $INPUT; 34 35 $cids = $INPUT->post->arr('cid'); 36 if (is_array($cids)) { 37 $cids = array_keys($cids); 38 } 39 /** @var action_plugin_discussion $action */ 40 $action = plugin_load('action', 'discussion'); 41 if (!$action) return; // couldn't load action plugin component 42 43 $act = $INPUT->post->str('comment'); 44 if ($act && !checkSecurityToken()) { 45 return; 46 } 47 switch ($act) { 48 case $lang['btn_delete']: 49 $action->save($cids, ''); 50 break; 51 52 case $this->getLang('btn_show'): 53 $action->save($cids, '', 'show'); 54 break; 55 56 case $this->getLang('btn_hide'): 57 $action->save($cids, '', 'hide'); 58 break; 59 60 case $this->getLang('btn_change'): 61 $this->changeStatus($INPUT->post->str('status')); 62 break; 63 } 64 } 65 66 public function html() 67 { 68 global $conf, $INPUT; 69 70 $first = $INPUT->int('first'); 71 72 $num = $conf['recent'] ?: 20; 73 74 ptln('<h1>' . $this->getLang('menu') . '</h1>'); 75 76 $threads = $this->getThreads(); 77 78 // slice the needed chunk of discussion pages 79 $isMore = count($threads) > ($first + $num); 80 $threads = array_slice($threads, $first, $num); 81 82 foreach ($threads as $thread) { 83 $comments = $this->getComments($thread); 84 $this->threadHead($thread); 85 if ($comments === false) { 86 ptln('</div>', 6); // class="level2" 87 continue; 88 } 89 90 ptln('<form method="post" action="' . wl($thread['id']) . '">', 8); 91 ptln('<div class="no">', 10); 92 ptln('<input type="hidden" name="do" value="admin" />', 10); 93 ptln('<input type="hidden" name="page" value="discussion" />', 10); 94 ptln('<input type="hidden" name="sectok" value="'.getSecurityToken().'" />', 10); 95 echo html_buildlist($comments, 'admin_discussion', [$this, 'commentItem'], [$this, 'liComment']); 96 $this->actionButtons(); 97 ptln('</div>', 10); // class="no" 98 ptln('</form>', 8); 99 ptln('</div>', 6); // class="level2" 100 } 101 $this->browseDiscussionLinks($isMore, $first, $num); 102 103 } 104 105 /** 106 * Returns an array of pages with discussion sections, sorted by recent comments 107 * 108 * @return array 109 */ 110 protected function getThreads() 111 { 112 global $conf; 113 114 // returns the list of pages in the given namespace and it's subspaces 115 $items = []; 116 search($items, $conf['datadir'], 'search_allpages', []); 117 118 // add pages with comments to result 119 $result = []; 120 foreach ($items as $item) { 121 $id = $item['id']; 122 123 // some checks 124 $file = metaFN($id, '.comments'); 125 if (!@file_exists($file)) continue; // skip if no comments file 126 127 $date = filemtime($file); 128 $result[] = [ 129 'id' => $id, 130 'file' => $file, 131 'date' => $date, 132 ]; 133 } 134 135 // finally sort by time of last comment 136 usort($result, ['admin_plugin_discussion', 'threadCmp']); 137 138 return $result; 139 } 140 141 /** 142 * Callback for comparison of thread data. 143 * 144 * Used for sorting threads in descending order by date of last comment. 145 * If this date happens to be equal for the compared threads, page id 146 * is used as second comparison attribute. 147 * 148 * @param array $a 149 * @param array $b 150 * @return int 151 */ 152 protected function threadCmp($a, $b) 153 { 154 if ($a['date'] == $b['date']) { 155 return strcmp($a['id'], $b['id']); 156 } 157 if ($a['date'] < $b['date']) { 158 return 1; 159 } else { 160 return -1; 161 } 162 } 163 164 /** 165 * Outputs header, page ID and status of a discussion thread 166 * 167 * @param array $thread 168 * @return bool 169 */ 170 protected function threadHead($thread) 171 { 172 global $lang; 173 174 $id = $thread['id']; 175 176 $labels = [ 177 0 => $this->getLang('off'), 178 1 => $this->getLang('open'), 179 2 => $this->getLang('closed') 180 ]; 181 $title = p_get_metadata($id, 'title'); 182 if (!$title) { 183 $title = $id; 184 } 185 $align = $lang['direction'] === 'rtl' ? 'left' : 'right'; 186 echo '<h2 name="' . $id . '" id="' . $id . '">' . hsc($title) . '</h2>' 187 . '<form method="post" action="' . wl($id) . '">' 188 . '<div class="media' . $align . '">' 189 . '<input type="hidden" name="do" value="admin" />' 190 . '<input type="hidden" name="page" value="discussion" />' 191 . '<input type="hidden" name="sectok" value="' . getSecurityToken() . '" />' 192 . $this->getLang('status') . ': ' 193 . '<select name="status" size="1">'; 194 foreach ($labels as $key => $label) { 195 $selected = ($key == $thread['status'] ? ' selected="selected"' : ''); 196 echo '<option value="' . $key . '"' . $selected . '>' . $label . '</option>'; 197 } 198 echo '</select> ' 199 . '<input type="submit" class="button" name="comment" value="' . $this->getLang('btn_change') . '" ' 200 . 'title="' . $this->getLang('btn_change') . '" />' 201 . '</div>' 202 . '</form>' 203 . '<div class="level2">' 204 . '<a href="' . wl($id) . '" class="wikilink1">' . $id . '</a> '; 205 return true; 206 } 207 208 /** 209 * Returns the full comments data for a given wiki page 210 * 211 * @param array $thread by reference with: 212 * 'id' => string page id, 213 * 'file' => string file location of .comments metadata file 214 * 'status' => int 215 * 'number' => int number of visible comments 216 * 217 * @return array|bool 218 */ 219 protected function getComments(&$thread) 220 { 221 $id = $thread['id']; 222 223 if (!$thread['file']) { 224 $thread['file'] = metaFN($id, '.comments'); 225 } 226 if (!@file_exists($thread['file'])) return false; // no discussion thread at all 227 228 $data = unserialize(io_readFile($thread['file'], false)); 229 230 $thread['status'] = $data['status']; 231 $thread['number'] = $data['number']; 232 if (empty($data['status'])) return false; // comments are turned off 233 if (empty($data['comments'])) return false; // no comments 234 235 $result = []; 236 foreach ($data['comments'] as $cid => $comment) { 237 $this->addComment($cid, $data, $result, $id); 238 } 239 240 if (empty($result)) { 241 return false; 242 } else { 243 return $result; 244 } 245 } 246 247 /** 248 * Recursive function to add the comment hierarchy to the result 249 * 250 * @param string $cid comment id of current comment 251 * @param array $data array with all comments by reference 252 * @param array $result array with all comments by reference enhanced with level 253 * @param string $id page id 254 * @param string $parent comment id of parent or empty 255 * @param int $level level of current comment, higher is deeper 256 */ 257 protected function addComment($cid, &$data, &$result, $id, $parent = '', $level = 1) 258 { 259 if (!isset($data['comments'][$cid]) || !is_array($data['comments'][$cid])) return; // corrupt datatype 260 261 $comment = $data['comments'][$cid]; 262 // handle only replies to given parent comment 263 if ($comment['parent'] != $parent) return; 264 265 // okay, add the comment to the result 266 $comment['id'] = $id; 267 $comment['level'] = $level; 268 $result[] = $comment; 269 270 // check answers to this comment 271 if (count($comment['replies'])) { 272 foreach ($comment['replies'] as $rid) { 273 $this->addComment($rid, $data, $result, $id, $cid, $level + 1); 274 } 275 } 276 } 277 278 /** 279 * Returns html of checkbox and info about a comment item 280 * 281 * @param array $comment array with comment data 282 * @return string html of checkbox and info 283 */ 284 public function commentItem($comment) 285 { 286 global $conf; 287 288 // prepare variables 289 if (is_array($comment['user'])) { // new format 290 $name = $comment['user']['name']; 291 $mail = $comment['user']['mail']; 292 } else { // old format 293 $name = $comment['name']; 294 $mail = $comment['mail']; 295 } 296 if (is_array($comment['date'])) { // new format 297 $created = $comment['date']['created']; 298 } else { // old format 299 $created = $comment['date']; 300 } 301 $abstract = preg_replace('/\s+?/', ' ', strip_tags($comment['xhtml'])); 302 if (PhpString::strlen($abstract) > 160) { 303 $abstract = PhpString::substr($abstract, 0, 160) . '...'; 304 } 305 306 return '<input type="checkbox" name="cid[' . $comment['cid'] . ']" value="1" /> ' 307 . $this->email($mail, $name, 'email') 308 . ', <a href="' . wl($comment['id']) . '#comment_' . $comment['cid'] . '" class="wikilink1">' 309 . strftime($conf['dformat'], $created) . ': ' 310 . '</a>' 311 . '<span class="abstract">' . $abstract . '</span>'; 312 } 313 314 /** 315 * Returns html of list item openings tag 316 * 317 * @param array $comment 318 * @return string 319 */ 320 public function liComment($comment) 321 { 322 $showclass = ($comment['show'] ? '' : ' hidden'); 323 return '<li class="level' . $comment['level'] . $showclass . '">'; 324 } 325 326 /** 327 * Show buttons to bulk remove, hide or show comments 328 */ 329 protected function actionButtons() 330 { 331 global $lang; 332 333 ptln('<div class="comment_buttons">', 12); 334 ptln('<input type="submit" name="comment" value="' . $this->getLang('btn_show') . '" class="button" title="' . $this->getLang('btn_show') . '" />', 14); 335 ptln('<input type="submit" name="comment" value="' . $this->getLang('btn_hide') . '" class="button" title="' . $this->getLang('btn_hide') . '" />', 14); 336 ptln('<input type="submit" name="comment" value="' . $lang['btn_delete'] . '" class="button" title="' . $lang['btn_delete'] . '" />', 14); 337 ptln('</div>', 12); // class="comment_buttons" 338 } 339 340 /** 341 * Displays links to older newer discussions 342 * 343 * @param bool $isMore whether there are more pages needed 344 * @param int $first first entry on this page 345 * @param int $num number of entries per page 346 */ 347 protected function browseDiscussionLinks($isMore, $first, $num) 348 { 349 global $ID; 350 351 if ($first == 0 && !$isMore) return; 352 353 $params = ['do' => 'admin', 'page' => 'discussion']; 354 $last = $first + $num; 355 ptln('<div class="level1">', 8); 356 $return = ''; 357 if ($first > 0) { 358 $first -= $num; 359 if ($first < 0) { 360 $first = 0; 361 } 362 $params['first'] = $first; 363 ptln('<p class="centeralign">', 8); 364 $return = '<a href="' . wl($ID, $params) . '" class="wikilink1"><< ' . $this->getLang('newer') . '</a>'; 365 if ($isMore) { 366 $return .= ' | '; 367 } else { 368 ptln($return, 10); 369 ptln('</p>', 8); 370 } 371 } elseif ($isMore) { 372 ptln('<p class="centeralign">', 8); 373 } 374 if ($isMore) { 375 $params['first'] = $last; 376 $return .= '<a href="' . wl($ID, $params) . '" class="wikilink1">' . $this->getLang('older') . ' >></a>'; 377 ptln($return, 10); 378 ptln('</p>', 8); 379 } 380 ptln('</div>', 6); // class="level1" 381 } 382 383 /** 384 * Changes the status of a comment section 385 * 386 * @param int $new 0=disabled, 1=enabled, 2=closed 387 */ 388 protected function changeStatus($new) 389 { 390 global $ID; 391 392 // get discussion meta file name 393 $file = metaFN($ID, '.comments'); 394 $data = unserialize(io_readFile($file, false)); 395 396 $old = $data['status']; 397 if ($old == $new) { 398 return; 399 } 400 401 // save the comment metadata file 402 $data['status'] = $new; 403 io_saveFile($file, serialize($data)); 404 405 // look for ~~DISCUSSION~~ command in page file and change it accordingly 406 $patterns = ['~~DISCUSSION:off\2~~', '~~DISCUSSION\2~~', '~~DISCUSSION:closed\2~~']; 407 $replace = $patterns[$new]; 408 $wiki = preg_replace('/~~DISCUSSION([\w:]*)(\|?.*?)~~/', $replace, rawWiki($ID)); 409 saveWikiText($ID, $wiki, $this->getLang('statuschanged'), true); 410 } 411} 412