1<?php 2 3use dokuwiki\ChangeLog\ChangeLog; 4use dokuwiki\Extension\SyntaxPlugin; 5use dokuwiki\File\PageResolver; 6 7/** 8 * Changes Plugin: List the most recent changes of the wiki 9 * 10 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 11 * @author Andreas Gohr <andi@splitbrain.org> 12 * @author Mykola Ostrovskyy <spambox03@mail.ru> 13 */ 14/** 15 * Class syntax_plugin_changes 16 */ 17class syntax_plugin_changes extends SyntaxPlugin 18{ 19 /** 20 * What kind of syntax are we? 21 */ 22 public function getType() 23 { 24 return 'substition'; 25 } 26 27 /** 28 * What type of XHTML do we create? 29 */ 30 public function getPType() 31 { 32 return 'block'; 33 } 34 35 /** 36 * Where to sort in? 37 */ 38 public function getSort() 39 { 40 return 105; 41 } 42 43 /** 44 * Connect pattern to lexer 45 * @param string $mode 46 */ 47 public function connectTo($mode) 48 { 49 $this->Lexer->addSpecialPattern('\{\{changes>[^}]*\}\}', $mode, 'plugin_changes'); 50 } 51 52 /** 53 * Handler to prepare matched data for the rendering process 54 * 55 * @param string $match The text matched by the patterns 56 * @param int $state The lexer state for the match 57 * @param int $pos The character position of the matched text 58 * @param Doku_Handler $handler The Doku_Handler object 59 * @return array Return an array with all data you want to use in render 60 */ 61 public function handle($match, $state, $pos, Doku_Handler $handler) 62 { 63 $match = substr($match, 10, -2); 64 65 $data = [ 66 'ns' => [], 67 'excludedpages' => [], 68 'count' => 10, 69 'type' => [], 70 'render' => 'list', 71 'render-flags' => [], 72 'maxage' => null, 73 'reverse' => false, 74 'user' => [], 75 'excludedusers' => [], 76 ]; 77 78 $match = explode('&', $match); 79 foreach ($match as $m) { 80 if (is_numeric($m)) { 81 $data['count'] = (int) $m; 82 } elseif (preg_match('/(\w+)\s*=(.+)/', $m, $temp) == 1) { 83 $this->handleNamedParameter($temp[1], trim($temp[2]), $data); 84 } else { 85 $this->addNamespace($data, trim($m)); 86 } 87 } 88 89 return $data; 90 } 91 92 /** 93 * Handle parameters that are specified using <name>=<value> syntax 94 * @param string $name 95 * @param $value 96 * @param array $data 97 */ 98 protected function handleNamedParameter($name, $value, &$data) 99 { 100 global $ID; 101 102 static $types = ['edit' => 'E', 'create' => 'C', 'delete' => 'D', 'minor' => 'e']; 103 static $renderers = ['list', 'pagelist']; 104 105 switch ($name) { 106 case 'count': 107 case 'maxage': 108 $data[$name] = (int) $value; 109 break; 110 case 'ns': 111 foreach (preg_split('/\s*,\s*/', $value) as $value) { 112 $this->addNamespace($data, $value); 113 } 114 break; 115 case 'type': 116 foreach (preg_split('/\s*,\s*/', $value) as $value) { 117 if (array_key_exists($value, $types)) { 118 $data[$name][] = $types[$value]; 119 } 120 } 121 break; 122 case 'render': 123 // parse "name(flag1, flag2)" syntax 124 if (preg_match('/(\w+)(?:\((.*)\))?/', $value, $match) == 1) { 125 if (in_array($match[1], $renderers)) { 126 $data[$name] = $match[1]; 127 if (count($match) > 2) { 128 $flags = trim($match[2]); 129 if ($flags != '') { 130 $data['render-flags'] = preg_split('/\s*,\s*/', $flags); 131 } 132 } 133 } 134 } 135 break; 136 case 'user': 137 case 'excludedusers': 138 foreach (preg_split('/\s*,\s*/', $value) as $value) { 139 $data[$name][] = $value; 140 } 141 break; 142 case 'excludedpages': 143 foreach (preg_split('/\s*,\s*/', $value) as $page) { 144 if (!empty($page)) { 145 $data[$name][] = (new PageResolver($ID))->resolveId($page); 146 } 147 } 148 break; 149 case 'reverse': 150 $data[$name] = (bool)$value; 151 break; 152 } 153 } 154 155 /** 156 * Clean-up the namespace name and add it (if valid) into the $data array 157 * @param array $data 158 * @param string $namespace 159 */ 160 protected function addNamespace(&$data, $namespace) 161 { 162 if (empty($namespace)) return; 163 $action = ($namespace[0] == '-') ? 'exclude' : 'include'; 164 $namespace = cleanID(preg_replace('/^[+-]/', '', $namespace)); 165 if (!empty($namespace)) { 166 $data['ns'][$action][] = $namespace; 167 } 168 } 169 170 /** 171 * Handles the actual output creation. 172 * 173 * @param string $format output format being rendered 174 * @param Doku_Renderer $renderer the current renderer object 175 * @param array $data data created by handler() 176 * @return boolean rendered correctly? 177 */ 178 public function render($format, Doku_Renderer $renderer, $data) 179 { 180 global $conf; 181 182 if ($format === 'xhtml') { 183 /* @var Doku_Renderer_xhtml $renderer */ 184 $changes = $this->getChanges( 185 $data['count'], 186 $data['ns'], 187 $data['excludedpages'], 188 $data['type'], 189 $data['user'], 190 $data['maxage'], 191 $data['excludedusers'], 192 $data['reverse'] 193 ); 194 if (!count($changes)) return true; 195 196 switch ($data['render']) { 197 case 'list': 198 $this->renderSimpleList($changes, $renderer, $data['render-flags']); 199 break; 200 case 'pagelist': 201 $this->renderPageList($changes, $renderer, $data['render-flags']); 202 break; 203 } 204 return true; 205 } elseif ($format === 'metadata') { 206 /* @var Doku_Renderer_metadata $renderer */ 207 $renderer->meta['relation']['depends']['rendering'][$conf['changelog']] = true; 208 $renderer->meta['relation']['depends']['rendering'][$conf['media_changelog']] = true; 209 return true; 210 } 211 return false; 212 } 213 214 /** 215 * Based on getRecents() from inc/changelog.php 216 * 217 * @param int $num 218 * @param array $ns 219 * @param array $excludedpages 220 * @param array $type 221 * @param array $user 222 * @param int $maxage 223 * @return array 224 */ 225 protected function getChanges($num, $ns, $excludedpages, $type, $user, $maxage, $excludedusers, $reverse) 226 { 227 global $conf; 228 $changes = []; 229 $seen = []; 230 $count = 0; 231 $lines = []; 232 233 // Get global changelog 234 if (file_exists($conf['changelog']) && is_readable($conf['changelog'])) { 235 $lines = @file($conf['changelog']); 236 } 237 238 // Merge media changelog 239 if ($this->getConf('listmedia')) { 240 if (file_exists($conf['media_changelog']) && is_readable($conf['media_changelog'])) { 241 $linesMedia = @file($conf['media_changelog']); 242 // Add a tag to identiy the media lines 243 foreach ($linesMedia as $key => $value) { 244 $value = ChangeLog::parseLogLine($value); 245 $value['extra'] = 'media'; 246 $linesMedia[$key] = implode("\t", $value) . "\n"; 247 } 248 $lines = array_merge($lines, $linesMedia); 249 } 250 } 251 252 if (is_null($maxage)) { 253 $maxage = (int) $conf['recent_days'] * 60 * 60 * 24; 254 } 255 256 for ($i = count($lines) - 1; $i >= 0; $i--) { 257 $change = $this->handleChangelogLine( 258 $lines[$i], 259 $ns, 260 $excludedpages, 261 $type, 262 $user, 263 $maxage, 264 $seen, 265 $excludedusers 266 ); 267 if ($change !== false) { 268 $changes[] = $change; 269 // break when we have enough entries 270 if (++$count >= $num) break; 271 } 272 } 273 274 // Date sort merged page and media changes 275 if ($this->getConf('listmedia') || $reverse) { 276 $dates = []; 277 foreach ($changes as $change) { 278 $dates[] = $change['date']; 279 } 280 array_multisort($dates, ($reverse ? SORT_ASC : SORT_DESC), $changes); 281 } 282 283 return $changes; 284 } 285 286 /** 287 * Based on _handleRecent() from inc/changelog.php 288 * 289 * @param string $line 290 * @param array $ns 291 * @param array $excludedpages 292 * @param array $type 293 * @param array $user 294 * @param int $maxage 295 * @param array $seen 296 * @return array|bool 297 */ 298 protected function handleChangelogLine($line, $ns, $excludedpages, $type, $user, $maxage, &$seen, $excludedusers) 299 { 300 // split the line into parts 301 $change = ChangeLog::parseLogLine($line); 302 if ($change === false) return false; 303 304 // skip seen ones 305 if (isset($seen[$change['id']])) return false; 306 307 // filter type 308 if (!empty($type) && !in_array($change['type'], $type)) return false; 309 310 // filter user 311 if (!empty($user) && (empty($change['user']) || !in_array($change['user'], $user))) return false; 312 313 // remember in seen to skip additional sights 314 $seen[$change['id']] = 1; 315 316 // show only not existing pages for delete 317 if ($change['extra'] != 'media' && $change['type'] != 'D' && !page_exists($change['id'])) return false; 318 319 // filter maxage 320 if ($maxage && $change['date'] < (time() - $maxage)) { 321 return false; 322 } 323 324 // check if it's a hidden page 325 if (isHiddenPage($change['id'])) return false; 326 327 // filter included namespaces 328 if (isset($ns['include'])) { 329 if (!$this->isInNamespace($ns['include'], $change['id'])) return false; 330 } 331 332 // filter excluded namespaces 333 if (isset($ns['exclude'])) { 334 if ($this->isInNamespace($ns['exclude'], $change['id'])) return false; 335 } 336 // exclude pages 337 if (!empty($excludedpages)) { 338 if (in_array($change['id'], $excludedpages)) { 339 return false; 340 } 341 } 342 343 // exclude users 344 if (!empty($excludedusers)) { 345 foreach ($excludedusers as $user) { 346 if ($change['user'] == $user) return false; 347 } 348 } 349 350 // check ACL 351 $change['perms'] = auth_quickaclcheck($change['id']); 352 if ($change['perms'] < AUTH_READ) return false; 353 354 return $change; 355 } 356 357 /** 358 * Check if page belongs to one of namespaces in the list 359 * 360 * @param array $namespaces 361 * @param string $id page id 362 * @return bool 363 */ 364 protected function isInNamespace($namespaces, $id) 365 { 366 foreach ($namespaces as $ns) { 367 if ((strpos($id, $ns . ':') === 0)) return true; 368 } 369 return false; 370 } 371 372 /** 373 * Render via the Pagelist plugin 374 * 375 * @param $changes 376 * @param Doku_Renderer_xhtml $renderer 377 * @param $flags 378 */ 379 protected function renderPageList($changes, $renderer, $flags) 380 { 381 /** @var helper_plugin_pagelist $pagelist */ 382 $pagelist = @plugin_load('helper', 'pagelist'); 383 if ($pagelist) { 384 $pagelist->setFlags($flags); 385 $pagelist->startList(); 386 foreach ($changes as $change) { 387 if ($change['extra'] == 'media') continue; 388 $page['id'] = $change['id']; 389 $page['date'] = $change['date']; 390 $page['user'] = $this->getUserName($change); 391 $page['desc'] = $change['sum']; 392 $pagelist->addPage($page); 393 } 394 $renderer->doc .= $pagelist->finishList(); 395 } else { 396 // Fallback to the simple list renderer 397 $this->renderSimpleList($changes, $renderer); 398 } 399 } 400 401 /** 402 * Render the day header 403 * 404 * @param Doku_Renderer $renderer 405 * @param int $date 406 */ 407 protected function dayheader($renderer, $date) 408 { 409 if ($renderer->getFormat() == 'xhtml') { 410 /* @var Doku_Renderer_xhtml $renderer */ 411 $renderer->doc .= '<h3 class="changes">'; 412 $renderer->cdata(dformat($date, $this->getConf('dayheaderfmt'))); 413 $renderer->doc .= '</h3>'; 414 } else { 415 $renderer->header(dformat($date, $this->getConf('dayheaderfmt')), 3, 0); 416 } 417 } 418 419 /** 420 * Render with a simple list render 421 * 422 * @param array $changes 423 * @param Doku_Renderer_xhtml $renderer 424 * @param array $flags 425 */ 426 protected function renderSimpleList($changes, $renderer, $flags = []) 427 { 428 global $conf; 429 $flags = $this->parseSimpleListFlags($flags); 430 431 $dayheaders_date = ''; 432 if ($flags['dayheaders']) { 433 $dayheaders_date = date('Ymd', $changes[0]['date']); 434 $this->dayheader($renderer, $changes[0]['date']); 435 } 436 437 $renderer->listu_open(); 438 foreach ($changes as $change) { 439 if ($flags['dayheaders']) { 440 $tdate = date('Ymd', $change['date']); 441 if ($tdate !== $dayheaders_date) { 442 $renderer->listu_close(); // break list to insert new header 443 $this->dayheader($renderer, $change['date']); 444 $renderer->listu_open(); 445 $dayheaders_date = $tdate; 446 } 447 } 448 449 $renderer->listitem_open(1); 450 $renderer->listcontent_open(); 451 if (trim($change['extra']) == 'media') { 452 $renderer->internalmedia(':' . $change['id'], null, null, null, null, null, 'linkonly'); 453 } else { 454 $renderer->internallink(':' . $change['id'], null, null, false, 'navigation'); 455 } 456 if ($flags['summary']) { 457 $renderer->cdata(' ' . $change['sum']); 458 } 459 if ($flags['signature']) { 460 $user = $this->getUserName($change); 461 $date = strftime($conf['dformat'], $change['date']); 462 $renderer->cdata(' '); 463 $renderer->entity('---'); 464 $renderer->cdata(' '); 465 $renderer->emphasis_open(); 466 $renderer->cdata($user . ' ' . $date); 467 $renderer->emphasis_close(); 468 } 469 $renderer->listcontent_close(); 470 $renderer->listitem_close(); 471 } 472 $renderer->listu_close(); 473 } 474 475 /** 476 * Parse flags for the simple list render 477 * 478 * @param array $flags 479 * @return array 480 */ 481 protected function parseSimpleListFlags($flags) 482 { 483 $outFlags = ['summary' => true, 'signature' => false, 'dayheaders' => false]; 484 if (!empty($flags)) { 485 foreach ($flags as $flag) { 486 if (array_key_exists($flag, $outFlags)) { 487 $outFlags[$flag] = true; 488 } elseif (substr($flag, 0, 2) == 'no') { 489 $flag = substr($flag, 2); 490 if (array_key_exists($flag, $outFlags)) { 491 $outFlags[$flag] = false; 492 } 493 } 494 } 495 } 496 return $outFlags; 497 } 498 499 /** 500 * Get username or fallback to ip 501 * 502 * @param array $change 503 * @return mixed 504 */ 505 protected function getUserName($change) 506 { 507 /* @var DokuWiki_Auth_Plugin $auth */ 508 global $auth; 509 if (!empty($change['user'])) { 510 $user = $auth->getUserData($change['user']); 511 if (empty($user)) { 512 return $change['user']; 513 } else { 514 return $user['name']; 515 } 516 } else { 517 return $change['ip']; 518 } 519 } 520} 521