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