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