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