1<?php 2 3namespace dokuwiki\Ui; 4 5use dokuwiki\ChangeLog\PageChangeLog; 6use dokuwiki\Ui\PageRevisions; 7use dokuwiki\Form\Form; 8 9/** 10 * DokuWiki PageDiff Interface 11 * 12 * @author Andreas Gohr <andi@splitbrain.org> 13 * @author Satoshi Sahara <sahara.satoshi@gmail.com> 14 * @package dokuwiki\Ui 15 */ 16class PageDiff extends Diff 17{ 18 /* @var PageChangeLog */ 19 protected $changelog; 20 21 /* @var string */ 22 protected $text; 23 24 /** 25 * PageDiff Ui constructor 26 * 27 * @param string $id page id 28 */ 29 public function __construct($id) 30 { 31 global $INFO; 32 if (!isset($id)) $id = $INFO['id']; 33 $this->item = 'page'; 34 35 // init preference 36 $this->preference['showIntro'] = true; 37 $this->preference['difftype'] = 'sidebyside'; // diff view type: inline or sidebyside 38 39 parent::__construct($id); 40 } 41 42 /** @inheritdoc */ 43 protected function setChangeLog() 44 { 45 $this->changelog = new PageChangeLog($this->id); 46 } 47 48 /** @inheritdoc */ 49 protected function itemFN($id, $rev = '') 50 { 51 return wikiFN($id, $rev); 52 } 53 54 /** 55 * Set text to be compared with most current version 56 * exclusively use of the compare($old, $new) method 57 * 58 * @param string $text 59 * @return $this 60 */ 61 public function compareWith($text = null) 62 { 63 if (isset($text)) { 64 $this->text = $text; 65 $this->oldRev = ''; 66 $this->newRev = null; 67 } 68 return $this; 69 } 70 71 /** @inheritdoc */ 72 protected function preProcess() 73 { 74 parent::preProcess(); 75 if (!isset($this->oldRev, $this->newRev)) { 76 // no revision was given, compare previous to current 77 $this->oldRev = $this->changelog->getRevisions(0, 1)[0]; 78 $this->newRev = ''; 79 80 global $INFO, $REV; 81 if ($this->id == $INFO['id']) 82 $REV = $this->oldRev; // store revision back in $REV 83 } 84 } 85 86 /** 87 * Show diff 88 * between current page version and provided $text 89 * or between the revisions provided via GET or POST 90 * 91 * @author Andreas Gohr <andi@splitbrain.org> 92 * 93 * @return void 94 */ 95 public function show() 96 { 97 global $INFO, $lang; 98 99 // determine left and right revision 100 if (!isset($this->oldRev)) $this->preProcess(); 101 102 // create difference engine object 103 if (isset($this->text)) { // compare text to the most current revision 104 $oldText = rawWiki($this->id, ''); 105 $newText = cleanText($this->text); 106 } else { 107 // when both revisions are empty then the page was created just now 108 $oldText = (!$this->oldRev && !$this->newRev) ? '' : rawWiki($this->id, $this->oldRev); 109 $newText = rawWiki($this->id, $this->newRev); // empty when removed page 110 } 111 $Difference = new \Diff(explode("\n", $oldText), explode("\n", $newText)); 112 113 // revison info of older page (left side) 114 $oldRevInfo = $this->getExtendedRevisionInfo($this->oldRev); 115 116 // revison info of newer page (right side) 117 if (isset($this->text)) { 118 $newRevInfo = array('date' => null); 119 } else { 120 $newRevInfo = $this->getExtendedRevisionInfo($this->newRev); 121 } 122 123 // determin exact revision identifiers, even for current page 124 $oldRev = $oldRevInfo['date']; 125 $newRev = $newRevInfo['date']; 126 127 // build paired navigation 128 $navOlderRevisions = ''; 129 $navNewerRevisions = ''; 130 if (!isset($this->text)) { 131 list( 132 $navOlderRevisions, 133 $navNewerRevisions, 134 ) = $this->buildRevisionsNavigation($oldRev, $newRev); 135 } 136 137 // display intro 138 if ($this->preference['showIntro']) echo p_locale_xhtml('diff'); 139 140 // print form to choose diff view type, and exact url reference to the view 141 if (!isset($this->text)) { 142 $this->showDiffViewSelector($oldRev, $newRev); 143 } 144 145 // assign minor edit checker to the variable 146 $classEditType = function ($info) { 147 return ($info['type'] === DOKU_CHANGE_TYPE_MINOR_EDIT) ? ' class="minor"' : ''; 148 }; 149 150 // display diff view table 151 echo '<div class="table">'; 152 echo '<table class="diff diff_'.$this->preference['difftype'] .'">'; 153 154 //navigation and header 155 switch ($this->preference['difftype']) { 156 case 'inline': 157 if (!isset($this->text)) { 158 echo '<tr>' 159 .'<td class="diff-lineheader">-</td>' 160 .'<td class="diffnav">'. $navOlderRevisions .'</td>' 161 .'</tr>'; 162 echo '<tr>' 163 .'<th class="diff-lineheader">-</th>' 164 .'<th'.$classEditType($oldRevInfo).'>'.$this->revisionTitle($oldRevInfo).'</th>' 165 .'</tr>'; 166 } 167 echo '<tr>' 168 .'<td class="diff-lineheader">+</td>' 169 .'<td class="diffnav">'. $navNewerRevisions .'</td>' 170 .'</tr>'; 171 echo '<tr>' 172 .'<th class="diff-lineheader">+</th>' 173 .'<th'.$classEditType($newRevInfo).'>'.$this->revisionTitle($newRevInfo).'</th>' 174 .'</tr>'; 175 // create formatter object 176 $DiffFormatter = new \InlineDiffFormatter(); 177 break; 178 179 case 'sidebyside': 180 default: 181 if (!isset($this->text)) { 182 echo '<tr>' 183 .'<td colspan="2" class="diffnav">'. $navOlderRevisions .'</td>' 184 .'<td colspan="2" class="diffnav">'. $navNewerRevisions .'</td>' 185 .'</tr>'; 186 } 187 echo '<tr>' 188 .'<th colspan="2"'.$classEditType($oldRevInfo).'>'.$this->revisionTitle($oldRevInfo).'</th>' 189 .'<th colspan="2"'.$classEditType($newRevInfo).'>'.$this->revisionTitle($newRevInfo).'</th>' 190 .'</tr>'; 191 // create formatter object 192 $DiffFormatter = new \TableDiffFormatter(); 193 break; 194 } 195 196 // output formatted difference 197 echo $this->insertSoftbreaks($DiffFormatter->format($Difference)); 198 199 echo '</table>'; 200 echo '</div>'; 201 } 202 203 /** 204 * Revision Title for PageDiff table headline 205 * 206 * @param array $info Revision info structure of a page 207 * @return string 208 */ 209 protected function revisionTitle(array $info) 210 { 211 global $lang, $INFO; 212 213 // use designated title when compare current page source with given text 214 if (array_key_exists('date', $info) && is_null($info['date'])) { 215 return $lang['yours']; 216 } 217 218 if (isset($info['date'])) { 219 $rev = $info['date']; 220 $title = '<bdi><a class="wikilink1" href="'.wl($this->id, ['rev' => $rev]).'">' 221 . $this->id.' ['.dformat($rev).']'.'</a></bdi>'; 222 } else { 223 $title = '—'; 224 } 225 if (isset($info['current']) || ($rev && $rev == $INFO['currentrev'])) { 226 $title .= ' ('.$lang['current'].')'; 227 } 228 229 // append separator 230 $title .= ($this->preference['difftype'] === 'inline') ? ' ' : '<br />'; 231 232 // supplement 233 if (isset($info['date'])) { 234 $objRevInfo = (new PageRevisions($this->id))->getObjRevInfo($info); 235 $title .= $objRevInfo->editSummary().' '.$objRevInfo->editor(); 236 } 237 return $title; 238 } 239 240 /** 241 * Print form to choose diff view type, and exact url reference to the view 242 * 243 * @param int $oldRev timestamp of older revision, left side 244 * @param int $newRev timestamp of newer revision, right side 245 */ 246 protected function showDiffViewSelector($oldRev, $newRev) 247 { 248 global $lang; 249 250 echo '<div class="diffoptions group">'; 251 252 // create the form to select difftype 253 $form = new Form(['action' => wl()]); 254 $form->setHiddenField('id', $this->id); 255 $form->setHiddenField('rev2[0]', $this->oldRev ?: 'current'); 256 $form->setHiddenField('rev2[1]', $this->newRev ?: 'current'); 257 $form->setHiddenField('do', 'diff'); 258 $options = array( 259 'sidebyside' => $lang['diff_side'], 260 'inline' => $lang['diff_inline'], 261 ); 262 $input = $form->addDropdown('difftype', $options, $lang['diff_type']) 263 ->val($this->preference['difftype']) 264 ->addClass('quickselect'); 265 $input->useInput(false); // inhibit prefillInput() during toHTML() process 266 $form->addButton('do[diff]', 'Go')->attr('type','submit'); 267 echo $form->toHTML(); 268 269 // show exact url reference to the view when it is meaningful 270 echo '<p>'; 271 if (!isset($this->text) && $oldRev && $newRev) { 272 // link to exactly this view FS#2835 273 $viewUrl = $this->diffViewlink('difflink', $oldRev, $newRev); 274 } 275 echo $viewUrl ?? '<br />'; 276 echo '</p>'; 277 278 echo '</div>'; // .diffoptions 279 } 280 281 /** 282 * Create html for revision navigation 283 * 284 * The navigation consists of older and newer revisions selectors, each 285 * state mutually depends on the selected revision of opposite side. 286 * 287 * @param int $oldRev timestamp of older revision, older side 288 * @param int $newRev timestamp of newer revision, newer side 289 * @return string[] html of navigation for both older and newer sides 290 */ 291 protected function buildRevisionsNavigation($oldRev, $newRev) 292 { 293 global $INFO; 294 295 $changelog =& $this->changelog; 296 297 // determine the last revision, which is usually the timestamp of current page, 298 // however which might be the last revision if the page had removed. 299 if (!$newRev) { 300 if ($this->id == $INFO['id']) { 301 // note: when page is removed, the metadata timestamp is zero 302 $lastRev = $INFO['currentrev'] ?? $INFO['meta']['last_change']['date'] ?? 0; 303 } else { 304 $lastRevs = $changelog->getRevisions(-1, 1) // empty array for removed page 305 ?: $changelog->getRevisions(0, 1); // last entry of changelog 306 $lastRev = count($last_revs) > 0 ? $last_revs[0] : 0; 307 } 308 $newRev = $lastRev; 309 } 310 311 // retrieve revisions with additional info 312 list($oldRevs, $newRevs) = $changelog->getRevisionsAround($oldRev, $newRev); 313 314 // build options for dropdown selector 315 $olderRevisions = $this->buildRevisionOptions('older', $oldRevs, $oldRev, $newRev); 316 $newerRevisions = $this->buildRevisionOptions('newer', $newRevs, $oldRev, $newRev); 317 318 //determine previous/next revisions 319 $index = array_search($oldRev, $oldRevs); 320 $oldPrevRev = $oldRevs[$index + 1]; 321 $oldNextRev = $oldRevs[$index - 1]; 322 if ($newRev) { 323 $index = array_search($newRev, $newRevs); 324 $newPrevRev = $newRevs[$index + 1]; 325 $newNextRev = $newRevs[$index - 1]; 326 } else { 327 //removed page 328 $newPrevRev = ($oldNextRev) ? $newRevs[0] : null; 329 $newNextRev = null; 330 } 331 332 /* 333 * navigation UI for older revisions / Left side: 334 */ 335 $navOlderRevs = ''; 336 //move back 337 if ($oldPrevRev) { 338 $navOlderRevs .= $this->diffViewlink('diffbothprevrev', $oldPrevRev, $newPrevRev); 339 $navOlderRevs .= $this->diffViewlink('diffprevrev', $oldPrevRev, $newRev); 340 } 341 //dropdown 342 $navOlderRevs .= $this->buildDropdownSelector('older', $olderRevisions, $oldRev, $newRev); 343 //move forward 344 if ($oldNextRev && ($oldNextRev < $newRev || !$newRev)) { 345 $navOlderRevs .= $this->diffViewlink('diffnextrev', $oldNextRev, $newRev); 346 } 347 348 /* 349 * navigation UI for newer revisions / Right side: 350 */ 351 $navNewerRevs = ''; 352 //move back 353 if ($oldRev < $newPrevRev) { 354 $navNewerRevs .= $this->diffViewlink('diffprevrev', $oldRev, $newPrevRev); 355 } 356 //dropdown 357 $navNewerRevs .= $this->buildDropdownSelector('newer', $newerRevisions, $oldRev, $newRev); 358 //move forward 359 if ($newNextRev) { 360 if ($changelog->isCurrentRevision($newNextRev)) { 361 //last revision is diff with current page 362 $navNewerRevs .= $this->diffViewlink('difflastrev', $oldRev); 363 } else { 364 $navNewerRevs .= $this->diffViewlink('diffnextrev', $oldRev, $newNextRev); 365 } 366 $navNewerRevs .= $this->diffViewlink('diffbothnextrev', $oldNextRev, $newNextRev); 367 } 368 return array($navOlderRevs, $navNewerRevs); 369 } 370 371 /** 372 * prepare options for dropdwon selector 373 * 374 * @params string $side "older" or "newer" 375 * @params array $revs list of revsion 376 * @param int $oldRev timestamp of older revision, left side 377 * @param int $newRev timestamp of newer revision, right side 378 * @return array 379 */ 380 protected function buildRevisionOptions($side, $revs, $oldRev, $newRev) 381 { 382 $changelog =& $this->changelog; 383 $revisions = array(); 384 385 if (($side == 'older' && !$oldRev) // NOTE: this case should not happen! 386 ||($side == 'newer' && (!$newRev || page_exists($this->id) == false)) 387 ) { 388 //no revision given, likely removed page, add dummy entry 389 $revisions['current'] = array( 390 'label' => '—', // U+2014 — 391 'attrs' => [], 392 ); 393 } 394 395 foreach ($revs as $rev) { 396 $info = $changelog->getRevisionInfo($rev); 397 $revisions[$rev] = array( 398 'label' => implode(' ', [ 399 dformat($info['date']), 400 editorinfo($info['user'], true), 401 $info['sum'], 402 ]), 403 'attrs' => ['title' => $rev], 404 ); 405 if (($side == 'older' && ($newRev ? $rev >= $newRev : false)) 406 ||($side == 'newer' && ($rev <= $oldRev)) 407 ) { 408 $revisions[$rev]['attrs']['disabled'] = 'disabled'; 409 } 410 } 411 return $revisions; 412 } 413 414 /** 415 * build Dropdown form for revisions navigation 416 * 417 * @params string $side "older" or "newer" 418 * @params array $options dropdown options 419 * @param int $oldRev timestamp of older revision, left side 420 * @param int $newRev timestamp of newer revision, right side 421 * @return sting 422 */ 423 protected function buildDropdownSelector($side, $options, $oldRev, $newRev) 424 { 425 $form = new Form(['action' => wl($this->id)]); 426 $form->setHiddenField('id', $this->id); 427 $form->setHiddenField('do', 'diff'); 428 $form->setHiddenField('difftype', $this->preference['difftype']); 429 430 switch ($side) { 431 case 'older': // left side 432 $form->setHiddenField('rev2[1]', $newRev ?: 'current'); 433 $input = $form->addDropdown('rev2[0]', $options) 434 ->val($oldRev ?: 'current')->addClass('quickselect'); 435 $input->useInput(false); // inhibit prefillInput() during toHTML() process 436 break; 437 case 'newer': // right side 438 $form->setHiddenField('rev2[0]', $oldRev ?: 'current'); 439 $input = $form->addDropdown('rev2[1]', $options) 440 ->val($newRev ?: 'current')->addClass('quickselect'); 441 $input->useInput(false); // inhibit prefillInput() during toHTML() process 442 break; 443 } 444 $form->addButton('do[diff]', 'Go')->attr('type','submit'); 445 return $form->toHTML(); 446 } 447 448 /** 449 * Create html link to a diff view defined by two revisions 450 * 451 * @param string $linktype 452 * @param int $oldRev older revision 453 * @param int $newRev newer revision or null for diff with current revision 454 * @return string html of link to a diff view 455 */ 456 protected function diffViewlink($linktype, $oldRev, $newRev = null) 457 { 458 global $lang; 459 if ($newRev === null) { 460 $urlparam = array( 461 'do' => 'diff', 462 'rev' => $oldRev, 463 'difftype' => $this->preference['difftype'], 464 ); 465 } else { 466 $urlparam = array( 467 'do' => 'diff', 468 'rev2[0]' => $oldRev, 469 'rev2[1]' => $newRev, 470 'difftype' => $this->preference['difftype'], 471 ); 472 } 473 $attr = array( 474 'class' => $linktype, 475 'href' => wl($this->id, $urlparam, true, '&'), 476 'title' => $lang[$linktype], 477 ); 478 return '<a '. buildAttributes($attr) .'><span>'. $lang[$linktype] .'</span></a>'; 479 } 480 481 482 /** 483 * Insert soft breaks in diff html 484 * 485 * @param string $diffhtml 486 * @return string 487 */ 488 public function insertSoftbreaks($diffhtml) 489 { 490 // search the diff html string for both: 491 // - html tags, so these can be ignored 492 // - long strings of characters without breaking characters 493 return preg_replace_callback('/<[^>]*>|[^<> ]{12,}/', function ($match) { 494 // if match is an html tag, return it intact 495 if ($match[0][0] == '<') return $match[0]; 496 // its a long string without a breaking character, 497 // make certain characters into breaking characters by inserting a 498 // word break opportunity (<wbr> tag) in front of them. 499 $regex = <<< REGEX 500(?(?= # start a conditional expression with a positive look ahead ... 501&\#?\\w{1,6};) # ... for html entities - we don't want to split them (ok to catch some invalid combinations) 502&\#?\\w{1,6}; # yes pattern - a quicker match for the html entity, since we know we have one 503| 504[?/,&\#;:] # no pattern - any other group of 'special' characters to insert a breaking character after 505)+ # end conditional expression 506REGEX; 507 return preg_replace('<'.$regex.'>xu', '\0<wbr>', $match[0]); 508 }, $diffhtml); 509 } 510 511} 512