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