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