1<?php 2 3namespace dokuwiki\Ui; 4 5use dokuwiki\ChangeLog\PageChangeLog; 6use dokuwiki\ChangeLog\MediaChangeLog; 7use dokuwiki\Extension\Event; 8use dokuwiki\Form\Form; 9 10/** 11 * DokuWiki Diff Interface 12 * 13 * @package dokuwiki\Ui 14 */ 15class Diff extends Ui 16{ 17 protected $text; 18 protected $showIntro; 19 protected $difftype; 20 21 /** 22 * Diff Ui constructor 23 * 24 * @param string $text when non-empty: compare with this text with most current version 25 * @param bool $showIntro display the intro text 26 * @param string $difftype diff view type (inline or sidebyside) 27 */ 28 public function __construct($text = '', $showIntro = true, $difftype = null) 29 { 30 $this->text = $text; 31 $this->showIntro = $showIntro; 32 33 // determine diff view type 34 if (isset($difftype)) { 35 $this->difftype = $difftype; 36 } else { 37 global $INPUT; 38 global $INFO; 39 $this->difftype = $INPUT->str('difftype') ?: get_doku_pref('difftype', $difftype); 40 if (empty($this->difftype) && $INFO['ismobile']) { 41 $this->difftype = 'inline'; 42 } 43 } 44 if ($this->difftype !== 'inline') $this->difftype = 'sidebyside'; 45 } 46 47 /** 48 * Show diff 49 * between current page version and provided $text 50 * or between the revisions provided via GET or POST 51 * 52 * @author Andreas Gohr <andi@splitbrain.org> 53 * 54 * @return void 55 */ 56 public function show() 57 { 58 global $ID; 59 global $REV; 60 global $lang; 61 global $INPUT; 62 global $INFO; 63 $pagelog = new PageChangeLog($ID); 64 65 /* 66 * Determine requested revision(s) 67 */ 68 // we're trying to be clever here, revisions to compare can be either 69 // given as rev and rev2 parameters, with rev2 being optional. Or in an 70 // array in rev2. 71 $rev1 = $REV; 72 73 $rev2 = $INPUT->ref('rev2'); 74 if (is_array($rev2)) { 75 $rev1 = (int) $rev2[0]; 76 $rev2 = (int) $rev2[1]; 77 78 if (!$rev1) { 79 $rev1 = $rev2; 80 unset($rev2); 81 } 82 } else { 83 $rev2 = $INPUT->int('rev2'); 84 } 85 86 /* 87 * Determine left and right revision, its texts and the header 88 */ 89 $r_minor = ''; 90 $l_minor = ''; 91 92 if ($this->text) { // compare text to the most current revision 93 $l_rev = ''; 94 $l_text = rawWiki($ID, ''); 95 $l_head = '<a class="wikilink1" href="'. wl($ID) .'">' 96 . $ID .' '. dformat((int) @filemtime(wikiFN($ID))) .'</a> ' 97 . $lang['current']; 98 99 $r_rev = ''; 100 $r_text = cleanText($this->text); 101 $r_head = $lang['yours']; 102 } else { 103 if ($rev1 && isset($rev2) && $rev2) { // two specific revisions wanted 104 // make sure order is correct (older on the left) 105 if ($rev1 < $rev2) { 106 $l_rev = $rev1; 107 $r_rev = $rev2; 108 } else { 109 $l_rev = $rev2; 110 $r_rev = $rev1; 111 } 112 } elseif ($rev1) { // single revision given, compare to current 113 $r_rev = ''; 114 $l_rev = $rev1; 115 } else { // no revision was given, compare previous to current 116 $r_rev = ''; 117 $revs = $pagelog->getRevisions(0, 1); 118 $l_rev = $revs[0]; 119 $REV = $l_rev; // store revision back in $REV 120 } 121 122 // when both revisions are empty then the page was created just now 123 if (!$l_rev && !$r_rev) { 124 $l_text = ''; 125 } else { 126 $l_text = rawWiki($ID, $l_rev); 127 } 128 $r_text = rawWiki($ID, $r_rev); 129 130 list($l_head, $r_head, $l_minor, $r_minor) = $this->diffHead( 131 $l_rev, $r_rev, null, false, ($this->difftype == 'inline') 132 ); 133 } 134 135 /* 136 * Build navigation 137 */ 138 $l_nav = ''; 139 $r_nav = ''; 140 if (!$this->text) { 141 list($l_nav, $r_nav) = $this->diffNavigation($pagelog, $l_rev, $r_rev); 142 } 143 /* 144 * Create diff object and the formatter 145 */ 146 $diff = new \Diff(explode("\n", $l_text), explode("\n", $r_text)); 147 148 if ($this->difftype == 'inline') { 149 $diffformatter = new \InlineDiffFormatter(); 150 } else { 151 $diffformatter = new \TableDiffFormatter(); 152 } 153 /* 154 * Display intro 155 */ 156 if ($this->showIntro) print p_locale_xhtml('diff'); 157 158 /* 159 * Display type and exact reference 160 */ 161 if (!$this->text) { 162 print '<div class="diffoptions group">'; 163 164 // create the form to select difftype 165 $form = new Form(['action' => wl()]); 166 $form->setHiddenField('id', $ID); 167 $form->setHiddenField('rev2[0]', $l_rev); 168 $form->setHiddenField('rev2[1]', $r_rev); 169 $form->setHiddenField('do', 'diff'); 170 $options = array( 171 'sidebyside' => $lang['diff_side'], 172 'inline' => $lang['diff_inline'] 173 ); 174 $input = $form->addDropdown('difftype', $options, $lang['diff_type']) 175 ->val($this->difftype)->addClass('quickselect'); 176 $input->useInput(false); // inhibit prefillInput() during toHTML() process 177 $form->addButton('do[diff]', 'Go')->attr('type','submit'); 178 print $form->toHTML(); 179 180 print '<p>'; 181 // link to exactly this view FS#2835 182 print $this->diffViewlink('difflink', $l_rev, ($r_rev ?: $INFO['currentrev'])); 183 print '</p>'; 184 185 print '</div>'; // .diffoptions 186 } 187 188 /* 189 * Display diff view table 190 */ 191 print '<div class="table">'; 192 print '<table class="diff diff_'. $this->difftype .'">'; 193 194 //navigation and header 195 if ($this->difftype == 'inline') { 196 if (!$this->text) { 197 print '<tr>' 198 . '<td class="diff-lineheader">-</td>' 199 . '<td class="diffnav">'. $l_nav .'</td>' 200 . '</tr>'; 201 print '<tr>' 202 . '<th class="diff-lineheader">-</th>' 203 . '<th '. $l_minor .'>'. $l_head .'</th>' 204 .'</tr>'; 205 } 206 print '<tr>' 207 . '<td class="diff-lineheader">+</td>' 208 . '<td class="diffnav">'. $r_nav .'</td>' 209 .'</tr>'; 210 print '<tr>' 211 . '<th class="diff-lineheader">+</th>' 212 . '<th '. $r_minor .'>'. $r_head .'</th>' 213 . '</tr>'; 214 } else { 215 if (!$this->text) { 216 print '<tr>' 217 . '<td colspan="2" class="diffnav">'. $l_nav .'</td>' 218 . '<td colspan="2" class="diffnav">'. $r_nav .'</td>' 219 . '</tr>'; 220 } 221 print '<tr>' 222 . '<th colspan="2" '. $l_minor .'>'. $l_head .'</th>' 223 . '<th colspan="2" '. $r_minor .'>'. $r_head .'</th>' 224 . '</tr>'; 225 } 226 227 //diff view 228 print $this->insertSoftbreaks($diffformatter->format($diff)); 229 230 print '</table>'; 231 print '</div>'. DOKU_LF; 232 } 233 234 235 /** 236 * Get header of diff HTML 237 * 238 * @param string $l_rev Left revisions 239 * @param string $r_rev Right revision 240 * @param string $id Page id, if null $ID is used 241 * @param bool $media If it is for media files 242 * @param bool $inline Return the header on a single line 243 * @return string[] HTML snippets for diff header 244 */ 245 public function diffHead($l_rev, $r_rev, $id = null, $media = false, $inline = false) 246 { 247 global $lang; 248 if ($id === null) { 249 global $ID; 250 $id = $ID; 251 } 252 $head_separator = $inline ? ' ' : '<br />'; 253 $media_or_wikiFN = $media ? 'mediaFN' : 'wikiFN'; 254 $ml_or_wl = $media ? 'ml' : 'wl'; 255 $l_minor = $r_minor = ''; 256 257 if ($media) { 258 $changelog = new MediaChangeLog($id); 259 } else { 260 $changelog = new PageChangeLog($id); 261 } 262 if (!$l_rev) { 263 $l_head = '—'; 264 } else { 265 $l_info = $changelog->getRevisionInfo($l_rev); 266 if ($l_info['user']) { 267 $l_user = '<bdi>'.editorinfo($l_info['user']).'</bdi>'; 268 if (auth_ismanager()) $l_user .= ' <bdo dir="ltr">('.$l_info['ip'].')</bdo>'; 269 } else { 270 $l_user = '<bdo dir="ltr">'.$l_info['ip'].'</bdo>'; 271 } 272 $l_user = '<span class="user">'.$l_user.'</span>'; 273 $l_sum = ($l_info['sum']) ? '<span class="sum"><bdi>'.hsc($l_info['sum']).'</bdi></span>' : ''; 274 if ($l_info['type'] === DOKU_CHANGE_TYPE_MINOR_EDIT) $l_minor = 'class="minor"'; 275 276 $l_head_title = ($media) ? dformat($l_rev) : $id.' ['.dformat($l_rev).']'; 277 $l_head = '<bdi><a class="wikilink1" href="'.$ml_or_wl($id,"rev=$l_rev").'">' 278 . $l_head_title.'</a></bdi>'.$head_separator.$l_user.' '.$l_sum; 279 } 280 281 if ($r_rev) { 282 $r_info = $changelog->getRevisionInfo($r_rev); 283 if ($r_info['user']) { 284 $r_user = '<bdi>'.editorinfo($r_info['user']).'</bdi>'; 285 if (auth_ismanager()) $r_user .= ' <bdo dir="ltr">('.$r_info['ip'].')</bdo>'; 286 } else { 287 $r_user = '<bdo dir="ltr">'.$r_info['ip'].'</bdo>'; 288 } 289 $r_user = '<span class="user">'.$r_user.'</span>'; 290 $r_sum = ($r_info['sum']) ? '<span class="sum"><bdi>'.hsc($r_info['sum']).'</bdi></span>' : ''; 291 if ($r_info['type'] === DOKU_CHANGE_TYPE_MINOR_EDIT) $r_minor = 'class="minor"'; 292 293 $r_head_title = ($media) ? dformat($r_rev) : $id.' ['.dformat($r_rev).']'; 294 $r_head = '<bdi><a class="wikilink1" href="'.$ml_or_wl($id,"rev=$r_rev").'">' 295 . $r_head_title.'</a></bdi>'.$head_separator.$r_user.' '.$r_sum; 296 } elseif ($_rev = @filemtime($media_or_wikiFN($id))) { 297 $_info = $changelog->getRevisionInfo($_rev); 298 if ($_info['user']) { 299 $_user = '<bdi>'.editorinfo($_info['user']).'</bdi>'; 300 if (auth_ismanager()) $_user .= ' <bdo dir="ltr">('.$_info['ip'].')</bdo>'; 301 } else { 302 $_user = '<bdo dir="ltr">'.$_info['ip'].'</bdo>'; 303 } 304 $_user = '<span class="user">'.$_user.'</span>'; 305 $_sum = ($_info['sum']) ? '<span class="sum"><bdi>'.hsc($_info['sum']).'</span></bdi>' : ''; 306 if ($_info['type'] === DOKU_CHANGE_TYPE_MINOR_EDIT) $r_minor = 'class="minor"'; 307 308 $r_head_title = ($media) ? dformat($_rev) : $id.' ['.dformat($_rev).']'; 309 $r_head = '<bdi><a class="wikilink1" href="'.$ml_or_wl($id).'">' 310 . $r_head_title.'</a></bdi> '.'('.$lang['current'].')'.$head_separator.$_user.' '.$_sum; 311 }else{ 312 $r_head = '— ('.$lang['current'].')'; 313 } 314 315 return array($l_head, $r_head, $l_minor, $r_minor); 316 } 317 318 /** 319 * Create html for revision navigation 320 * 321 * @param PageChangeLog $pagelog changelog object of current page 322 * @param int $l_rev left revision timestamp 323 * @param int $r_rev right revision timestamp 324 * @return string[] html of left and right navigation elements 325 */ 326 protected function diffNavigation($pagelog, $l_rev, $r_rev) 327 { 328 global $INFO, $ID; 329 330 // last timestamp is not in changelog, retrieve timestamp from metadata 331 // note: when page is removed, the metadata timestamp is zero 332 if (!$r_rev) { 333 if (isset($INFO['meta']['last_change']['date'])) { 334 $r_rev = $INFO['meta']['last_change']['date']; 335 } else { 336 $r_rev = 0; 337 } 338 } 339 340 //retrieve revisions with additional info 341 list($l_revs, $r_revs) = $pagelog->getRevisionsAround($l_rev, $r_rev); 342 $l_revisions = array(); 343 if (!$l_rev) { 344 //no left revision given, add dummy 345 $l_revisions[0]= array('label' => '', 'attrs' => []); 346 } 347 foreach ($l_revs as $rev) { 348 $info = $pagelog->getRevisionInfo($rev); 349 $l_revisions[$rev] = array( 350 'label' => dformat($info['date']) .' '. editorinfo($info['user'], true) .' '. $info['sum'], 351 'attrs' => ['title' => $rev], 352 ); 353 if ($r_rev ? $rev >= $r_rev : false) $l_revisions[$rev]['attrs']['disabled'] = 'disabled'; 354 } 355 $r_revisions = array(); 356 if (!$r_rev) { 357 //no right revision given, add dummy 358 $r_revisions[0] = array('label' => '', 'attrs' => []); 359 } 360 foreach ($r_revs as $rev) { 361 $info = $pagelog->getRevisionInfo($rev); 362 $r_revisions[$rev] = array( 363 'label' => dformat($info['date']) .' '. editorinfo($info['user'], true) .' '. $info['sum'], 364 'attrs' => ['title' => $rev], 365 ); 366 if ($rev <= $l_rev) $r_revisions[$rev]['attrs']['disabled'] = 'disabled'; 367 } 368 369 //determine previous/next revisions 370 $l_index = array_search($l_rev, $l_revs); 371 $l_prev = $l_revs[$l_index + 1]; 372 $l_next = $l_revs[$l_index - 1]; 373 if ($r_rev) { 374 $r_index = array_search($r_rev, $r_revs); 375 $r_prev = $r_revs[$r_index + 1]; 376 $r_next = $r_revs[$r_index - 1]; 377 } else { 378 //removed page 379 if ($l_next) { 380 $r_prev = $r_revs[0]; 381 } else { 382 $r_prev = null; 383 } 384 $r_next = null; 385 } 386 387 /* 388 * Left side: 389 */ 390 $l_nav = ''; 391 //move back 392 if ($l_prev) { 393 $l_nav .= $this->diffViewlink('diffbothprevrev', $l_prev, $r_prev); 394 $l_nav .= $this->diffViewlink('diffprevrev', $l_prev, $r_rev); 395 } 396 //dropdown 397 $form = new Form(['action' => wl()]); 398 $form->setHiddenField('id', $ID); 399 $form->setHiddenField('difftype', $this->difftype); 400 $form->setHiddenField('rev2[1]', $r_rev); 401 $form->setHiddenField('do', 'diff'); 402 $input = $form->addDropdown('rev2[0]', $l_revisions)->val($l_rev)->addClass('quickselect'); 403 $input->useInput(false); // inhibit prefillInput() during toHTML() process 404 $form->addButton('do[diff]', 'Go')->attr('type','submit'); 405 $l_nav .= $form->toHTML(); 406 //move forward 407 if ($l_next && ($l_next < $r_rev || !$r_rev)) { 408 $l_nav .= $this->diffViewlink('diffnextrev', $l_next, $r_rev); 409 } 410 411 /* 412 * Right side: 413 */ 414 $r_nav = ''; 415 //move back 416 if ($l_rev < $r_prev) { 417 $r_nav .= $this->diffViewlink('diffprevrev', $l_rev, $r_prev); 418 } 419 //dropdown 420 $form = new Form(['action' => wl()]); 421 $form->setHiddenField('id', $ID); 422 $form->setHiddenField('rev2[0]', $l_rev); 423 $form->setHiddenField('difftype', $this->difftype); 424 $form->setHiddenField('do', 'diff'); 425 $input = $form->addDropdown('rev2[1]', $r_revisions)->val($r_rev)->addClass('quickselect'); 426 $input->useInput(false); // inhibit prefillInput() during toHTML() process 427 $form->addButton('do[diff]', 'Go')->attr('type','submit'); 428 $r_nav .= $form->toHTML(); 429 //move forward 430 if ($r_next) { 431 if ($pagelog->isCurrentRevision($r_next)) { 432 //last revision is diff with current page 433 $r_nav .= $this->diffViewlink('difflastrev', $l_rev); 434 } else { 435 $r_nav .= $this->diffViewlink('diffnextrev', $l_rev, $r_next); 436 } 437 } else { 438 $r_nav .= $this->diffViewlink('diffbothnextrev', $l_next, $r_next); 439 } 440 return array($l_nav, $r_nav); 441 } 442 443 /** 444 * Create html link to a diff view defined by two revisions 445 * 446 * @param string $linktype 447 * @param int $lrev oldest revision 448 * @param int $rrev newest revision or null for diff with current revision 449 * @return string html of link to a diff view 450 */ 451 protected function diffViewlink($linktype, $lrev, $rrev = null) 452 { 453 global $ID, $lang; 454 if ($rrev === null) { 455 $urlparam = array( 456 'do' => 'diff', 457 'rev' => $lrev, 458 'difftype' => $this->difftype, 459 ); 460 } else { 461 $urlparam = array( 462 'do' => 'diff', 463 'rev2[0]' => $lrev, 464 'rev2[1]' => $rrev, 465 'difftype' => $this->difftype, 466 ); 467 } 468 return '<a class="'. $linktype .'" href="'. wl($ID, $urlparam) .'" title="'. $lang[$linktype] .'">' 469 . '<span>'. $lang[$linktype] .'</span>' 470 . '</a>'. DOKU_LF; 471 } 472 473 474 /** 475 * Insert soft breaks in diff html 476 * 477 * @param string $diffhtml 478 * @return string 479 */ 480 protected function insertSoftbreaks($diffhtml) 481 { 482 // search the diff html string for both: 483 // - html tags, so these can be ignored 484 // - long strings of characters without breaking characters 485 return preg_replace_callback('/<[^>]*>|[^<> ]{12,}/', function ($match) { 486 // if match is an html tag, return it intact 487 if ($match[0][0] == '<') return $match[0]; 488 // its a long string without a breaking character, 489 // make certain characters into breaking characters by inserting a 490 // word break opportunity (<wbr> tag) in front of them. 491 $regex = <<< REGEX 492(?(?= # start a conditional expression with a positive look ahead ... 493&\#?\\w{1,6};) # ... for html entities - we don't want to split them (ok to catch some invalid combinations) 494&\#?\\w{1,6}; # yes pattern - a quicker match for the html entity, since we know we have one 495| 496[?/,&\#;:] # no pattern - any other group of 'special' characters to insert a breaking character after 497)+ # end conditional expression 498REGEX; 499 return preg_replace('<'.$regex.'>xu', '\0<wbr>', $match[0]); 500 }, $diffhtml); 501 } 502 503} 504