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