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 = null) 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 $rev = false; 224 $title = '—'; 225 } 226 if (isset($info['current']) || ($rev && $rev == $INFO['currentrev'])) { 227 $title .= ' ('.$lang['current'].')'; 228 } 229 230 // append separator 231 $title .= ($this->preference['difftype'] === 'inline') ? ' ' : '<br />'; 232 233 // supplement 234 if (isset($info['date'])) { 235 $objRevInfo = (new PageRevisions($this->id))->getObjRevInfo($info); 236 $title .= $objRevInfo->editSummary().' '.$objRevInfo->editor(); 237 } 238 return $title; 239 } 240 241 /** 242 * Print form to choose diff view type, and exact url reference to the view 243 * 244 * @param int $oldRev timestamp of older revision, left side 245 * @param int $newRev timestamp of newer revision, right side 246 */ 247 protected function showDiffViewSelector($oldRev, $newRev) 248 { 249 global $lang; 250 251 echo '<div class="diffoptions group">'; 252 253 // create the form to select difftype 254 $form = new Form(['action' => wl()]); 255 $form->setHiddenField('id', $this->id); 256 $form->setHiddenField('rev2[0]', $this->oldRev ?: 'current'); 257 $form->setHiddenField('rev2[1]', $this->newRev ?: 'current'); 258 $form->setHiddenField('do', 'diff'); 259 $options = array( 260 'sidebyside' => $lang['diff_side'], 261 'inline' => $lang['diff_inline'], 262 ); 263 $input = $form->addDropdown('difftype', $options, $lang['diff_type']) 264 ->val($this->preference['difftype']) 265 ->addClass('quickselect'); 266 $input->useInput(false); // inhibit prefillInput() during toHTML() process 267 $form->addButton('do[diff]', 'Go')->attr('type','submit'); 268 echo $form->toHTML(); 269 270 // show exact url reference to the view when it is meaningful 271 echo '<p>'; 272 if (!isset($this->text) && $oldRev && $newRev) { 273 // link to exactly this view FS#2835 274 $viewUrl = $this->diffViewlink('difflink', $oldRev, $newRev); 275 } 276 echo $viewUrl ?? '<br />'; 277 echo '</p>'; 278 279 echo '</div>'; // .diffoptions 280 } 281 282 /** 283 * Create html for revision navigation 284 * 285 * The navigation consists of older and newer revisions selectors, each 286 * state mutually depends on the selected revision of opposite side. 287 * 288 * @param int $oldRev timestamp of older revision, older side 289 * @param int $newRev timestamp of newer revision, newer side 290 * @return string[] html of navigation for both older and newer sides 291 */ 292 protected function buildRevisionsNavigation($oldRev, $newRev) 293 { 294 global $INFO; 295 296 $changelog =& $this->changelog; 297 298 // determine the last revision, which is usually the timestamp of current page, 299 // however which might be the last revision if the page had removed. 300 if (!$newRev) { 301 if ($this->id == $INFO['id']) { 302 // note: when page is removed, the metadata timestamp is zero 303 $lastRev = $INFO['currentrev'] ?? $INFO['meta']['last_change']['date'] ?? 0; 304 } else { 305 $lastRevs = $changelog->getRevisions(-1, 1) // empty array for removed page 306 ?: $changelog->getRevisions(0, 1); // last entry of changelog 307 $lastRev = count($lastRevs) > 0 ? $lastRevs[0] : 0; 308 } 309 $newRev = $lastRev; 310 } 311 312 // retrieve revisions with additional info 313 list($oldRevs, $newRevs) = $changelog->getRevisionsAround($oldRev, $newRev); 314 315 // build options for dropdown selector 316 $olderRevisions = $this->buildRevisionOptions('older', $oldRevs, $oldRev, $newRev); 317 $newerRevisions = $this->buildRevisionOptions('newer', $newRevs, $oldRev, $newRev); 318 319 //determine previous/next revisions 320 $index = array_search($oldRev, $oldRevs); 321 $oldPrevRev = $oldRevs[$index + 1]; 322 $oldNextRev = $oldRevs[$index - 1]; 323 if ($newRev) { 324 $index = array_search($newRev, $newRevs); 325 $newPrevRev = $newRevs[$index + 1]; 326 $newNextRev = $newRevs[$index - 1]; 327 } else { 328 //removed page 329 $newPrevRev = ($oldNextRev) ? $newRevs[0] : null; 330 $newNextRev = null; 331 } 332 333 /* 334 * navigation UI for older revisions / Left side: 335 */ 336 $navOlderRevs = ''; 337 //move back 338 if ($oldPrevRev) { 339 $navOlderRevs .= $this->diffViewlink('diffbothprevrev', $oldPrevRev, $newPrevRev); 340 $navOlderRevs .= $this->diffViewlink('diffprevrev', $oldPrevRev, $newRev); 341 } 342 //dropdown 343 $navOlderRevs .= $this->buildDropdownSelector('older', $olderRevisions, $oldRev, $newRev); 344 //move forward 345 if ($oldNextRev && ($oldNextRev < $newRev || !$newRev)) { 346 $navOlderRevs .= $this->diffViewlink('diffnextrev', $oldNextRev, $newRev); 347 } 348 349 /* 350 * navigation UI for newer revisions / Right side: 351 */ 352 $navNewerRevs = ''; 353 //move back 354 if ($oldRev < $newPrevRev) { 355 $navNewerRevs .= $this->diffViewlink('diffprevrev', $oldRev, $newPrevRev); 356 } 357 //dropdown 358 $navNewerRevs .= $this->buildDropdownSelector('newer', $newerRevisions, $oldRev, $newRev); 359 //move forward 360 if ($newNextRev) { 361 if ($changelog->isCurrentRevision($newNextRev)) { 362 //last revision is diff with current page 363 $navNewerRevs .= $this->diffViewlink('difflastrev', $oldRev); 364 } else { 365 $navNewerRevs .= $this->diffViewlink('diffnextrev', $oldRev, $newNextRev); 366 } 367 $navNewerRevs .= $this->diffViewlink('diffbothnextrev', $oldNextRev, $newNextRev); 368 } 369 return array($navOlderRevs, $navNewerRevs); 370 } 371 372 /** 373 * prepare options for dropdwon selector 374 * 375 * @params string $side "older" or "newer" 376 * @params array $revs list of revsion 377 * @param int $oldRev timestamp of older revision, left side 378 * @param int $newRev timestamp of newer revision, right side 379 * @return array 380 */ 381 protected function buildRevisionOptions($side, $revs, $oldRev, $newRev) 382 { 383 $changelog =& $this->changelog; 384 $revisions = array(); 385 386 if (($side == 'older' && !$oldRev) // NOTE: this case should not happen! 387 ||($side == 'newer' && (!$newRev || !page_exists($this->id))) 388 ) { 389 //no revision given, likely removed page, add dummy entry 390 $revisions['current'] = array( 391 'label' => '—', // U+2014 — 392 'attrs' => [], 393 ); 394 } 395 396 foreach ($revs as $rev) { 397 $info = $changelog->getRevisionInfo($rev); 398 $revisions[$rev] = array( 399 'label' => implode(' ', [ 400 dformat($info['date']), 401 editorinfo($info['user'], true), 402 $info['sum'], 403 ]), 404 'attrs' => ['title' => $rev], 405 ); 406 if (($side == 'older' && ($newRev ? $rev >= $newRev : false)) 407 ||($side == 'newer' && ($rev <= $oldRev)) 408 ) { 409 $revisions[$rev]['attrs']['disabled'] = 'disabled'; 410 } 411 } 412 return $revisions; 413 } 414 415 /** 416 * build Dropdown form for revisions navigation 417 * 418 * @params string $side "older" or "newer" 419 * @params array $options dropdown options 420 * @param int $oldRev timestamp of older revision, left side 421 * @param int $newRev timestamp of newer revision, right side 422 * @return sting 423 */ 424 protected function buildDropdownSelector($side, $options, $oldRev, $newRev) 425 { 426 $form = new Form(['action' => wl($this->id)]); 427 $form->setHiddenField('id', $this->id); 428 $form->setHiddenField('do', 'diff'); 429 $form->setHiddenField('difftype', $this->preference['difftype']); 430 431 switch ($side) { 432 case 'older': // left side 433 $form->setHiddenField('rev2[1]', $newRev ?: 'current'); 434 $input = $form->addDropdown('rev2[0]', $options) 435 ->val($oldRev ?: 'current')->addClass('quickselect'); 436 $input->useInput(false); // inhibit prefillInput() during toHTML() process 437 break; 438 case 'newer': // right side 439 $form->setHiddenField('rev2[0]', $oldRev ?: 'current'); 440 $input = $form->addDropdown('rev2[1]', $options) 441 ->val($newRev ?: 'current')->addClass('quickselect'); 442 $input->useInput(false); // inhibit prefillInput() during toHTML() process 443 break; 444 } 445 $form->addButton('do[diff]', 'Go')->attr('type','submit'); 446 return $form->toHTML(); 447 } 448 449 /** 450 * Create html link to a diff view defined by two revisions 451 * 452 * @param string $linktype 453 * @param int $oldRev older revision 454 * @param int $newRev newer revision or null for diff with current revision 455 * @return string html of link to a diff view 456 */ 457 protected function diffViewlink($linktype, $oldRev, $newRev = null) 458 { 459 global $lang; 460 if ($newRev === null) { 461 $urlparam = array( 462 'do' => 'diff', 463 'rev' => $oldRev, 464 'difftype' => $this->preference['difftype'], 465 ); 466 } else { 467 $urlparam = array( 468 'do' => 'diff', 469 'rev2[0]' => $oldRev, 470 'rev2[1]' => $newRev, 471 'difftype' => $this->preference['difftype'], 472 ); 473 } 474 $attr = array( 475 'class' => $linktype, 476 'href' => wl($this->id, $urlparam, true, '&'), 477 'title' => $lang[$linktype], 478 ); 479 return '<a '. buildAttributes($attr) .'><span>'. $lang[$linktype] .'</span></a>'; 480 } 481 482 483 /** 484 * Insert soft breaks in diff html 485 * 486 * @param string $diffhtml 487 * @return string 488 */ 489 public function insertSoftbreaks($diffhtml) 490 { 491 // search the diff html string for both: 492 // - html tags, so these can be ignored 493 // - long strings of characters without breaking characters 494 return preg_replace_callback('/<[^>]*>|[^<> ]{12,}/', function ($match) { 495 // if match is an html tag, return it intact 496 if ($match[0][0] == '<') return $match[0]; 497 // its a long string without a breaking character, 498 // make certain characters into breaking characters by inserting a 499 // word break opportunity (<wbr> tag) in front of them. 500 $regex = <<< REGEX 501(?(?= # start a conditional expression with a positive look ahead ... 502&\#?\\w{1,6};) # ... for html entities - we don't want to split them (ok to catch some invalid combinations) 503&\#?\\w{1,6}; # yes pattern - a quicker match for the html entity, since we know we have one 504| 505[?/,&\#;:] # no pattern - any other group of 'special' characters to insert a breaking character after 506)+ # end conditional expression 507REGEX; 508 return preg_replace('<'.$regex.'>xu', '\0<wbr>', $match[0]); 509 }, $diffhtml); 510 } 511 512} 513