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